Skip to content

fix(cli): sample real pixels behind hidden text for contrast-audit backgrounds#2102

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/svg-fill-contrast-audit
Jul 9, 2026
Merged

fix(cli): sample real pixels behind hidden text for contrast-audit backgrounds#2102
miguel-heygen merged 3 commits into
mainfrom
fix/svg-fill-contrast-audit

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes five reported false-positive/false-negative patterns in the WCAG contrast audit (hyperframes validate --contrast):

  1. SVG fill vs. text color — foreground read from CSS color instead of SVG fill.
  2. Cross-component color bleed — background estimate bleeds into a neighboring panel/layer.
  3. Backdrop-filter glass text — background estimate misses the blur/tint and reads the raw backdrop.
  4. Partially-overlapping translucent decoration — a decorative shape inside or partly touching the text's bbox goes undetected.
  5. Solid-fill pill/button — investigated, did not reproduce; already handled correctly by the existing own-background ancestor walk. Not touched.

Why

The audit estimated an element's background two ways:

  • foreground: always getComputedStyle(el).color — wrong for SVG <text>/<tspan>, which is painted via fill, an independent CSS property.
  • background: a 4px pixel ring sampled just outside the text's bounding box, with a fallback to an ancestor's opaque background-color for solid pills/buttons.

The ring is a proximity heuristic. It's wrong whenever what's immediately outside the text differs from what's actually behind it:

  • text near the edge of its own panel, with a differently-colored sibling panel/layer just past the bbox — the ring samples the neighbor.
  • a backdrop-filter: blur() glass panel sized only a couple pixels larger than the text — the ring exits the panel into the raw, unblurred, untinted backdrop.
  • a translucent decoration that only partially overlaps the ring, or sits entirely inside the bbox — invisible to the ring regardless of size.

How

SVG fill (#1): elements inside an <svg> (el.ownerSVGElement) now prefer the computed fill when it resolves to a solid rgb()/rgba() color, falling back to color for paint values that aren't a plain color (none, context-fill, gradient/pattern refs).

Cross-comp bleed / glass blur / partial decoration (#2#4): replaced the ring-sampling + own-background-ancestor-walk heuristic with a two-phase capture:

  1. __contrastAuditPrepare() walks the DOM, computes each candidate's foreground (unchanged logic from Initial repo setup #1), and hides that element's own text paint (color/filltransparent, layout-neutral — no reflow).
  2. The caller takes one screenshot with the glyphs invisible (same number of screenshots as before — just moved after the hide instead of before it).
  3. __contrastAuditFinish(imgBase64, time, candidates) restores the original paint immediately, then samples the real composited pixels directly inside each element's own bbox — no proximity heuristic needed, since these are the exact pixels that were behind the glyphs.

This is a real architectural change to contrast-audit.browser.js's calling contract (single __contrastAudit__contrastAuditPrepare/__contrastAuditFinish), with validate.ts's runContrastAudit updated to match, including a try/finally restore-safety-net so a mid-loop screenshot/decode failure can't leave a later sample auditing a page with stale hidden text.

Mirrored the identical change in skills/hyperframes-creative/scripts/contrast-report.mjs, which duplicates the same DOM-walk/sampling logic (not just the WCAG math). There, the visible frame for the human-facing overlay image still comes from the producer's normal captureFrameToBuffer path (unchanged); only the background-sampling capture is a plain session.page.screenshot() taken after hiding text — deliberately bypassing captureFrameToBuffer, whose static-frame dedup cache knows nothing about the DOM mutation and would hand back a stale pre-mutation buffer.

Solid-fill pill (#5): reproduced a rounded pill/button with a busy page background outside it. The existing own-background ancestor walk already resolves the pill's declared background-color correctly regardless of the rounded corners — confirmed via repro, both before and after this change report the identical (correct) result. No fix needed; left untouched, and this case is covered by the new architecture too (would give the same right answer even without the ancestor-walk fallback).

Added packages/cli/src/commands/contrast-sample.ts (mirroring the existing contrast-bg.ts/contrast-fg.ts pattern) hosting the pure sample-rect/grid-point computation, unit tested — the browser-injected scripts can't import it directly, so it's kept in sync by hand, same convention as the rest of this file.

Test plan

  • Unit tests: contrast-fg.test.ts (SVG fill resolution), contrast-sample.test.ts (sample-rect clamping/degenerate cases), plus the full packages/cli suite (1424 tests) passes, including an updated layout-audit.browser.test.ts case that called the old single-function __contrastAudit API directly.
  • Manual verification — standalone puppeteer-core harness against real chrome-headless-shell, one minimal HTML fixture per pattern, comparing the audit's reported ratio/verdict against a hand-constructed ground truth:
    • SVG fill: fill:white / no color on black bg → before: fg=rgb(0,0,0) ratio 1:1 (false FAIL); after: fg=rgb(255,255,255) ratio 21:1 (correct PASS).
    • Cross-comp bleed: text on a black sibling highlight box 2px larger than the text, white page bg outside it → before: bg=rgb(255,255,255) ratio 1.23:1 (false FAIL); after: bg=rgb(0,0,0) ratio 17.14:1 (correct PASS).
    • Glass blur: black text on an 18%-white-tinted backdrop-filter: blur(14px) panel over a yellow/blue gradient, panel only ~2px larger than the text → before: bg=rgb(0,64,255) (raw gradient color, blur/tint completely missed) ratio 3.18:1 (false FAIL); after: bg=rgb(159,160,165) (correct blurred/tinted blend) ratio 8.05:1 (correct PASS).
    • Partial decoration: text 92%-covered by a translucent white badge on a dark bg → before: bg=rgb(16,16,16) (ring never touches the badge, which sits entirely inside the bbox) ratio 17.45:1 (false PASS); after: bg=rgb(171,171,171) (correctly detects the badge) ratio 2.11:1 (correct FAIL).
    • Solid pill sanity: unaffected — bg=rgb(10,10,10) ratio 19.8:1 before and after.
  • End-to-end: ran the actual hyperframes validate --contrast CLI command (via tsx src/cli.ts) against a real scaffolded project containing all 4 patterns simultaneously — only the genuinely-failing case (the 92%-covered decoration) is reported (1.09:1, need 3:1); the cross-comp-bleed, glass-blur, and solid-pill cases are correctly silent. A second vanilla scaffold with plain white-on-dark text produces zero false positives.
  • oxlint, oxfmt --check, and tsc --noEmit all pass on the changed files.

…round detection

SVG text (<text>/<tspan>/<textPath>) is painted via the `fill` property, not
`color` — the two are independent in SVG. When a composition sets `fill`
without also setting `color`, getComputedStyle(el).color resolves to the
inherited/initial value (often black), which does not match what's actually
rendered on screen. The contrast audit was reading `cs.color` unconditionally,
so any SVG text with fill != color got a fabricated foreground and a
false pass/fail verdict.

Add an SVG-aware foreground read: for elements inside an <svg> (detected via
ownerSVGElement), prefer the computed `fill` when it resolves to a solid
rgb()/rgba() color, falling back to `color` for paint values that aren't a
plain color (none, context-fill, gradient/pattern refs) so we never crash
parseColor or report a fabricated black.

Mirrored the same fix in skills/hyperframes-creative/scripts/contrast-report.mjs,
which duplicates the DOM-walk/foreground-read logic (not just the WCAG math),
and updated the header sync note (the referenced path had also drifted to
skills/hyperframes-creative). Extracted the pure decision into contrast-fg.ts
(mirroring the existing contrast-bg.ts pattern) with unit tests, since the
browser-injected script itself can't be unit-tested without a real page.

Manually verified with a standalone puppeteer-core repro against the cached
chrome-headless-shell binary: an SVG <text fill="white"> with no `color` set
on a black background.
  Before: fg=rgb(0,0,0) bg=rgb(0,0,0) ratio=1 wcagAA=false (false fail)
  After:  fg=rgb(255,255,255) bg=rgb(0,0,0) ratio=21 wcagAA=true (correct)
Also verified fill: none and fill: url(#gradient) fall back to `color`
without crashing.

Scope: this addresses only the SVG fill-vs-color mismatch. The other 4
reported contrast-audit false-positive patterns (cross-comp color bleed,
solid-fill button/pill, translucent glass-text layers, translucent
decorative/animated elements) are out of scope and remain open.
…t backgrounds

The contrast audit estimated an element's background by sampling a 4px
ring just outside its bounding box. That proximity heuristic is wrong
whenever what's immediately outside the text differs from what's
actually behind it:

  - cross-component bleed: text sits near the edge of its own panel/
    layer, and a differently-colored sibling panel/layer starts just
    outside the text's bbox, so the ring samples the neighbor instead
    of the real background.
  - backdrop-filter glass text: a translucent blurred panel sized only
    a couple pixels larger than the text — the ring exits the panel and
    samples the raw, unblurred, untinted pixels behind it.
  - partially-overlapping translucent decoration: a decorative shape
    that only partly overlaps the ring, or sits entirely INSIDE the
    text's own bbox, which the ring can never see regardless of size.

(Solid-fill pill/button backgrounds were already handled correctly by
the existing own-background ancestor walk and are unaffected by this
change — confirmed via repro, not touched.)

Fix: hide each candidate text element's own paint (color/fill set to
transparent, layout-neutral — no reflow), take ONE screenshot with the
glyphs invisible, then sample the real composited pixels directly
INSIDE the element's own bbox. No proximity heuristic needed since
we're reading the exact pixels that were behind the glyphs. Same
number of screenshots as before (one per sample time), just moved to
after the hide instead of before it.

This changes contrast-audit.browser.js from a single __contrastAudit
call into a two-phase __contrastAuditPrepare / __contrastAuditFinish
contract (see validate.ts's runContrastAudit for the calling side, with
a try/finally restore-safety-net so a mid-loop failure can't leave
later samples auditing a page with stale hidden text). Mirrored the
same change in skills/hyperframes-creative/scripts/contrast-report.mjs,
which duplicates the same DOM-walk/sampling logic — there the visible
frame still comes from the producer's normal capture path (for the
overlay image); only the background-sampling capture is a plain
page.screenshot() after hiding text, deliberately bypassing the
capture pipeline's static-frame dedup cache, which knows nothing about
the DOM mutation and would hand back a stale pre-mutation buffer.

Added contrast-sample.ts (mirroring the existing contrast-bg.ts /
contrast-fg.ts pattern) hosting the pure sample-rect/grid-point
computation, unit tested, since the two browser-injected scripts can't
import it directly.

Verified via a standalone puppeteer-core harness against real fixtures
for each pattern, then end-to-end through the actual `hyperframes
validate --contrast` CLI command against a real scaffolded project:
before this change, a text run sitting mostly on a translucent
decorative badge reported as a false PASS (ring never sees decoration
fully inside the bbox); after, it correctly reports ~1.1:1 and fails.
A cross-component-bleed case and a backdrop-filter glass-text case that
previously reported false FAILs (ring bleeding into a neighboring
panel / the raw unblurred backdrop) now correctly report as passing,
matching their true rendered contrast.
@miguel-heygen miguel-heygen changed the title fix(cli): read SVG fill instead of CSS color for contrast-audit foreground detection fix(cli): sample real pixels behind hidden text for contrast-audit backgrounds Jul 9, 2026

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at e0ff3b7.

Solid piece of work — the ring → inside-bbox shift is a real architectural improvement, the SVG fill fix is orthogonal but justified, and the two-phase prepare / finish contract is cleanly done. Both commit messages are exemplary root-cause writeups. Inline comments below on three concerns (all around the "hidden text" safety net's edges), plus a note on the 5th-pattern verification. None gating.

The 5th (solid-fill pill) "investigated, didn't reproduce" claim is credible on its own merits — the new inside-bbox sampling handles rounded pills through pill-color-dominates-median, and the historical pickOpaqueBackground ancestor-walk is preserved for legacy callers. Worth noting there's no in-repo regression test for the pill class: contrast-sample.test.ts covers the pure rect/grid math, contrast-fg.test.ts covers foreground resolution, but neither pins the rounded-pill scenario end-to-end. If it ever regresses, CI wouldn't catch it — only a manual repro would. A rounded-pill DOM fixture added to layout-audit.browser.test.ts (which already sets up contrast-audit.browser.js via the two-phase entry) would be cheap.

CI is green at HEAD on the new run (29031178042), with Windows render + Tests still in progress at review time.

Review by Rames D Jusso

var sample = function (sx, sy) {
if (sx < 0 || sx >= w || sy < 0 || sy >= h) return;
var idx = (sy * w + sx) * 4;
window.__contrastAuditRestores = restores;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Race between partial-hide and the safety net: window.__contrastAuditRestores = restores is set only at the END of the walker loop. If prepare() throws mid-walk (e.g. a foreign iframe element yields an unexpected getComputedStyle, or a rare setProperty failure on a shadow-DOM node), some elements are already color: transparent !important but window.__contrastAuditRestores is still undefined — so __contrastAuditRestoreIfPending in validate.ts's finally returns early (if (!restores) return;) and the partial-hide leaks for the rest of the puppeteer page lifetime. The blast is bounded by the CLI invocation (page closes at exit), but it's a one-line fix: hoist window.__contrastAuditRestores = restores; to BEFORE the while ((node = walker.nextNode())) loop. restores is passed by reference, so subsequent restores.push(...) calls remain visible via the window property as the walk progresses. Same fix applies to contrast-report.mjs. — Rames D Jusso

// inline value (or remove the property entirely) afterward.
var origColor = el.style.getPropertyValue("color");
var origColorPriority = el.style.getPropertyPriority("color");
el.style.setProperty("color", "transparent", "important");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CSS transition contamination on the hidden-text screenshot: if any candidate element has a transition on color (or fill, for SVG text) in the composition's stylesheet, the setProperty("color", "transparent", "important") here starts a transition rather than an instantaneous switch. The screenshot fires ~immediately after prepare() returns via page.evaluate, but there's a small window (single-digit ms) where glyphs are mid-transition and still partially visible — which corrupts the "hidden text" premise the whole two-phase approach rests on, since the sampled pixels would include a fading glyph tint rather than the true composited background. Real-world compositions with animated text colors are rare, but this is the load-bearing assumption of the fix. Cheap defensive addition: also set transition: none !important on color (and fill when isSvgText) in prepare, capture their prior values in the same restores entry, and restore in __contrastAuditRestoreAll. — Rames D Jusso

Comment thread packages/cli/src/commands/validate.ts Outdated
// partially-overlapping translucent decoration in ways a ring isn't.
const candidates = (await page.evaluate(() =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditPrepare === "function"
? ((window as unknown as Record<string, unknown>).__contrastAuditPrepare as () => unknown)()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 The prepare() page.evaluate runs OUTSIDE the try { screenshot + finish } finally { restore } scope — if it rejects with the DOM walker mid-flight (see also contrast-audit.browser.js line 230), no restore fires because the finally block hasn't started. With the browser-side fix suggested there, this becomes defense-in-depth rather than load-bearing, but broadening the try to try { const candidates = await ...prepare(...); ...screenshot; ...finish; } finally { restore } closes both gaps at once with one wrap-around. — Rames D Jusso

bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
});
}
window.__contrastReportRestores = restores;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mirrored version of the contrast-audit.browser.js:230 concern — window.__contrastReportRestores = restores is set only at the END of the walker loop, so a mid-walk throw leaves elements hidden with no restorable state (restoreTextElements reads window.__contrastReportRestores and no-ops if it's null). Same one-line fix: hoist the assignment above the for (const node of walkTextElements(...)) loop, or equivalent placement above the walker. Applying the fix in both files at once keeps the two paths in structural sync as the header comment demands. — Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 LGTM concurring with Rames-D — two-phase capture is a real architectural improvement over the ring heuristic; concurring with Rames-D's three 🟡 holds and one 🟢 defense-in-depth ask, all non-gating.

Verified independently at head e0ff3b73.

Verified

  • Two-phase contract mechanism is sound. __contrastAuditPrepare walks document.body, hides each candidate's own paint via el.style.setProperty("color", "transparent", "important") (plus fill for ownerSVGElement-carrying nodes), builds an ordered restore list at window.__contrastAuditRestores. Caller takes ONE page.screenshot() (packages/cli/src/commands/validate.ts:337), passes base64 into __contrastAuditFinish, which — critically — calls __contrastAuditRestoreAll() BEFORE decoding the image (packages/cli/src/commands/contrast-audit.browser.js:230), so a slow/failed Image decode can never leave the page stuck with invisible text.
  • Restore is layout-neutral. Only color and fill are touched, and only via inline style (no class swaps, no attribute writes to fill=""); prior inline value + priority are preserved exactly (contrast-audit.browser.js:181-192). No reflow, so subsequent getBoundingClientRect reads inside the same iteration are still valid.
  • Screenshot-throw + finish-throw safety net. runContrastAudit wraps try { screenshot + finish } finally { __contrastAuditRestoreIfPending() } (validate.ts:359-368). If page.screenshot() throws, or the finish page.evaluate throws before its internal __contrastAuditRestoreAll() runs, the outer finally still restores — provided __contrastAuditRestores was populated. (The prepare-throw path is not covered — see Rames-D's holds below.)
  • SVG fill-vs-color fix (pattern #1). contrast-audit.browser.js:157tryParseSolidColor(cs.fill) || parseColor(cs.color), guarded by ownerSVGElement. contrast-fg.test.ts covers none, context-fill, url(#grad), garbage values, alpha preservation.
  • Cross-component bleed (pattern #2). Old ring sampled outside bbox (the neighbor panel); new sampler is computeSampleRect(bbox) (contrast-audit.browser.js:263-267, unit-tested in contrast-sample.test.ts) with a 1px anti-aliasing inset, reading pixels inside the text's own bbox. The neighbor panel is structurally invisible to this sampler.
  • Backdrop-filter glass text (pattern #3). Same mechanism — sampling inside the bbox reads whatever the compositor produced there. backdrop-filter composites into the panel's own box, so the blurred/tinted result lands in the median.
  • Partially-overlapping translucent decoration (pattern #4). Bounded grid sampleGridPoints (up to 13×7 samples with a stepX = max(1, (x1-x0)/12) / stepY = max(1, (y1-y0)/6) scan) covers the bbox interior; median filter — a >50% overlap moves the median, which matches the intended "partial decoration should count" semantics.
  • No stragglers on the old __contrastAudit API. Code search returns only the browser script, validate.ts (uses new API), layout-audit.browser.test.ts (updated in this PR), and a .fallowrc.jsonc allowlist comment.
  • Regression tests. contrast-sample.test.ts (5 rect + 3 grid cases), contrast-fg.test.ts (7 fg-resolution cases), plus the migrated clip-path visibility test in layout-audit.browser.test.ts — exercises the full DOM-walk → hide → sample flow via happy-dom.
  • CI. All required checks pass at read time. Render on windows-latest and Tests on windows-latest still pending — historically non-gating.
  • Blast radius stays inside CLI validate. Audit called only from validateInBrowser under if (opts.contrast). No Studio/SDK/engine consumers; frameCapture.ts's reference is a doc comment about the page.addScriptTag injection pattern, not a real dep.
  • Commit envelope is clean. No Co-Authored-By: Claude in either commit; no 🤖 Generated with [Claude Code] in the PR body.

Concurring with Rames-D's holds

All three 🟡 and the 🟢 ask land; the first two hit the load-bearing assumption of the two-phase design.

  • 🟡 contrast-audit.browser.js:230 — walker-loop race. window.__contrastAuditRestores = restores is set at the END of the walker, so a mid-walk prepare() throw leaves elements already hidden with restores never assigned to window. The outer finally's __contrastAuditRestoreIfPending reads if (!restores) return; and no-ops. Concur on the one-line fix: hoist the assignment ABOVE the walker loop — restores is the same array reference either way.
  • 🟡 contrast-audit.browser.js:202transition: color contamination. This is the load-bearing hazard for the whole two-phase premise. If any candidate has transition: color <t> in a stylesheet, setProperty("color", "transparent", "important") starts an animation rather than an instantaneous switch, and the screenshot fires within a single-digit-ms window during which glyphs are still partially visible. Real-world compositions with animated text colors are uncommon but not zero, and the failure mode is silent (contrast ratios computed against a slightly tinted background). Concur — Rames-D's ephemeral transition: none !important override (or a getComputedStyle skip on nonzero transitionDuration for color/fill) is the least invasive countermeasure.
  • 🟢 validate.ts:314prepare() outside try/finally. Defense-in-depth once the browser-side hoist above lands; broadening the outer try to wrap prepare() too closes the gap at both layers with one edit.
  • 🟡 contrast-report.mjs:223 — mirrored version of the walker-loop race. Same END-assignment shape (window.__contrastReportRestores = restores). The header comment demands structural sync between the two paths — fix both at once.

Observations (non-blocking, unique to this review)

  • O1 — shadow DOM / iframe blind spot. document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT) doesn't cross shadow roots or same-origin iframes. Pre-existing (old code used the same walker) but the "sample real pixels" framing invites the question — if a shadow-DOM <hyperframes-caption>-style element ever ships, the audit will silently skip it rather than crash. Latent, not a regression.
  • O2 — CSS content: attr(...) and ::before / ::after glyphs. Elements whose only visible text is a pseudo-element fail the direct-text-node hasText check (contrast-audit.browser.js:118) and are filtered. Pre-existing. Pseudo-element text on an element that also has direct text does hide via inheritance from the inline color — that half works.
  • O3 — inline !important hide vs author-side !important. Contrived, but a stylesheet rule of the form [style*="color"] { color: red !important } could beat the inline hide on specificity tie-breaks. Not a real composition pattern today; naming for completeness.
  • O4 — GPU vs software compositor variance. Empirical before/after ratios in the PR body are single-config. Median + 1px inset should absorb sub-pixel AA differences, but PRODUCER_BROWSER_GPU_MODE=software (validate.ts:459-463) exercises a different rasterizer path — worth spot-checking once if convenient.

R1 by Via

…es and slow transitions

Four follow-ups from review of the hide-text/sample-bbox background
change:

- Register the restores list (window.__contrastAuditRestores /
  __contrastReportRestores) BEFORE the DOM walk starts, and push into
  it incrementally as each element is hidden, instead of assigning it
  only after the walk finishes. If getComputedStyle/
  getBoundingClientRect throws partway through (a detached or
  otherwise pathological element), everything hidden before the throw
  is now still reachable for restore instead of leaking hidden until
  the next full audit. Repro'd directly: monkey-patched the second of
  three text elements' getBoundingClientRect to throw mid-walk — before
  this fix the first element's hide was unrecoverable since the
  restores array was never published; after, __contrastAuditRestoreIfPending()
  correctly restores it.

- Force `transition: none` (important) alongside color/fill when
  hiding text, and restore it alongside them. A `transition` on
  color/fill otherwise animates the hide instead of applying it
  instantly, so the screenshot taken right after can land mid-transition
  and catch a partially-visible glyph, contaminating the background
  sample. Repro'd with a `transition: color 4s` element: before this
  fix, direct pixel sampling of the "hidden" screenshot showed a fully
  unhidden (pure original-color) pixel at the glyph edge on every run;
  after, zero contamination across repeated runs.

- Move the __contrastAuditPrepare() call inside the try block in
  validate.ts's runContrastAudit, as its first statement, so a throw
  from prepare() itself is covered by the existing
  finally-restore-safety-net. Previously prepare() ran before the try,
  so a mid-prepare failure skipped the restore entirely.

- Added a regression test locking in the "solid-fill pill is already
  correct" investigation finding: constructs a dark pill/button on a
  bright busy page background with real (non-flat) per-pixel screenshot
  data, exercises the actual two-phase prepare()/finish() path, and
  asserts the text isn't flagged — so a future change to the
  bbox-sampling logic that regressed this case would fail loudly
  instead of silently.

All four changes mirrored between contrast-audit.browser.js and
skills/hyperframes-creative/scripts/contrast-report.mjs where
applicable (the report script doesn't have a separate caller-side
try/finally to fix — its own try/finally around the screenshot+measure
call already wraps prepare()).
@miguel-heygen miguel-heygen merged commit 1614dd3 into main Jul 9, 2026
81 of 87 checks passed
@miguel-heygen miguel-heygen deleted the fix/svg-fill-contrast-audit branch July 9, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants